在開始今天的文章前我想先介紹一下世界上最好用的影音處理工具之一 - FFmpeg,現今你在市面上看到的大部分功能都可以透過FFmpeg來實現(甚至某些軟體都是FFmpeg套一層皮,如: 格式工廠等)
FFmpeg支援的格式非常多,幾乎可以說是所有的格式都支援,並且本身支援多平台,所以你可以在Windows、Linux、MacOS上使用FFmpeg,所以你可以在官網上下載到最新的版本。
FFmpeg有三個主要的工具,分別是ffmpeg
、ffplay
、ffprobe
,其中
接下來的操作都是以ffmpeg
為主,ffprobe
只會在需要查看影片資訊時使用
-h
: 顯示幫助-i <filename>
: 輸入檔案-ss <time>
: 指定起始時間(time
: "HH:MM:SS.xxx", 其中HH代表小時, MM代表分鐘, SS代表秒, xxx代表毫秒, 前面補0)-to <time>
: 指定結束時間-vframes <number>
: 指定要輸出的frame數-t <duration>
: 指定持續時間(duration
: 秒)-c:v <codec>
: 指定視訊編碼器(也可以用-vcodec
來指定)真的記不起來的話可以透過ffmpeg -h
來查看,或者是網路上找ffmpeg指令生成器FFmpeg 指令產生器
在Linux上可以透過以下指令來安裝
sudo apt-get install ffmpeg # Ubuntu, Debian
sudo yum install ffmpeg # CentOS, Fedora
sudo dnf install ffmpeg # Fedora 22以後
sudo pacman -S ffmpeg # Arch Linux, Manjaro
在Windows上可以透過以下指令來安裝,或是去官網下載
choco install ffmpeg
首先,如果要擷取特定時間範圍的影片,我們需要指定開始時間與結束時間,可以透過-ss
與-to
來指定,假設我要擷取從第10秒到第20秒的影片,可以透過以下指令來實現
ffmpeg -i input.mp4 -ss 00:00:10 -to 00:00:20 -c copy output.mp4
也可以透過-t
來指定持續時間,或是透過-vframes
來指定要輸出的frame數,假設我要擷取第10秒到第20秒的影片,可以透過以下指令來實現
ffmpeg -i input.mp4 -ss 00:00:10 -t 10 -c copy output.mp4
而對於圖片的擷取,只需要指定-ss
就行了,假設我要擷取第10秒的影片,可以透過以下指令來實現
ffmpeg -i input.mp4 -ss 00:00:10 -vframes 1 output.jpg
然而,我的資料只能抓到各個台詞的起始frame與結束frame,所以我們寫一個function將frame轉換為ffmpeg的時間格式
// convert frame to ffmpeg time format("HH:MM:SS.mmm")
function frame_to_ffmpeg_time(){
# $1: frame: int
# $2: fps: float
# return: "%02d:%02d:%02d.%03d"
frame=$1
fps=$2
frame_time=$(echo "scale=3; $frame / $fps" | bc)
seconds=$(echo $frame_time | cut -d '.' -f 1)
milliseconds=$(echo $frame_time | cut -d '.' -f 2)
hours=$(echo $seconds / 3600 | bc)
minutes=$(echo $seconds / 60 % 60 | bc)
seconds=$(echo $seconds % 60 | bc)
printf "%02d:%02d:%02d.%03d" $hours $minutes $seconds $milliseconds
}
frame_to_ffmpeg_time $1 $2
這時可能有人要問了,你的資料只有frame,那fps怎麼來的?這時我們可以透過ffprobe
來查看影片的fps
ffprobe -v error -select_streams v:0 -show_entries stream=r_frame_rate -of default=noprint_wrappers=1:nokey=1 input.mp4
# > 24000/1001
mygo
├── data
│ ├── data.go
│ ├── db.go
│ ├── go.mod
│ └── go.sum
├── data.json
├── main.go
├── go.mod
├── go.sum
└── video
├── go.mod
├── go.sum
└── video.go
接下來的流程跟昨天一樣,在mygo
資料夾中建立video
資料夾,並在video
資料夾中初始化module為mygo/video
,接著在mygo
資料夾的go.mod
中加入replace
來指定video
資料夾的module。
module mygo
go <current version>
replace mygo/video => ./video
replace mygo/data => ./data
require mygo/video v0.0.0
require mygo/data v0.0.0
接下來就是透過Go來實作上面的功能了
func FrameToTime(frame int, fps float64) string {
sec := float64(frame) / fps
min := int(sec / 60)
hour := int(min / 60)
sec = sec - float64(min*60)
min = min % 60
return fmt.Sprintf("%02d:%02d:%06.3f", hour, min, sec)
}
這邊我們透過go-ffprobe來對影片進行解析,並獲取fps
import (
"context"
"fmt"
"log"
"os"
"runtime"
"strconv"
"time"
ffprobe "gopkg.in/vansante/go-ffprobe.v2"
)
func FetchVideoFPS(videoPath string) (int, float64) {
ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second)
defer cancelFn()
data, err := ffprobe.ProbeURL(ctx, videoPath)
if err != nil {
log.Panicf("Error getting data: %v", err)
}
frame, err := strconv.Atoi(data.Streams[0].NbFrames)
if err != nil {
log.Panicf("Error getting frame: %v", err)
}
var num, den float64
fmt.Sscanf(data.Streams[0].RFrameRate, "%f/%f", &num, &den)
fps := num / den
return frame, fps
}
至於路徑的部份因人而異,不過建議將檔名改為<episode>.mp4
,這樣在後續的操作中會比較方便
const videoPath = "%s/mygo/%s.mp4"
var homePath = os.Getenv("HOME")
func process(){
episode := "1-3"
if runtime.GOOS == "windows" {
homePath = os.Getenv("USERPROFILE")
}
frame, fps := FetchVideoFPS(fmt.Sprintf(videoPath, homePath, episode))
}
從剛才的介紹我們知道可以透過以下指令來擷取圖片
ffmpeg -i "${episode}.mp4" -ss $(frame_to_ffmpeg_time ${frame} ${fps}) -vframes 1 "${episode}.jpg"
但是要怎麼透過Go來執行這個指令呢?這時我們可以透過ffmpeg-go套件來執行,如果你之前有使用過ffmpeg-python的話,這個套件的操作方式跟ffmpeg-python很像
import (
"fmt"
"os"
"os/exec"
"runtime"
ffmpeg "github.com/u2takey/ffmpeg-go"
)
func ExtractFrame(episode string, frameNumber int, fps float64) (*bytes.Buffer, error) {
if frameNumber < 0 {
return nil, fmt.Errorf("frame number must be positive")
}
if runtime.GOOS == "windows" {
homePath = os.Getenv("USERPROFILE")
}
videoPath := fmt.Sprintf("%s/mygo-anime/%s.mp4", homePath, episode)
fmt.Printf("Extracting frame %d from %s\n", frameNumber, videoPath)
buf := &bytes.Buffer{}
err := ffmpeg.Input(videoPath, ffmpeg.KwArgs{"ss": FrameToTime(frameNumber, fps)}).
Output("pipe:", ffmpeg.KwArgs{"vframes": 1, "format": "image2", "vcodec": "mjpeg"}).
WithOutput(buf, os.Stdout).
Run()
if err != nil {
return nil, err
}
fmt.Printf("Extracted frame %d from %s\n", frameNumber, videoPath)
return buf, nil
}
附上ffmpeg-python的寫法
from pathlib import Path
from typing import Literal
import ffmpeg
def extract_frame(
episode: Literal["1-3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13"],
fps: float,
frame_number: int) -> bytes:
video_path = Path.home() / "mygo-anime" / f"{episode}.mp4"
process = ffmpeg.input(
video_path,
ss=frame_to_time(frame_number, fps),
).output("pipe:", vframes=1, format="image2", vcodec="mjpeg")
result, _ = process.run(capture_stdout=True)
return result
擷取GIF的方式跟擷取圖片的方式很像,只是在Output
時要指定format
為gif
,並且要指定vcodec
為gif
,這樣就可以將影片轉換為GIF了
然後FFmpeg支援反向擷取,因此我們需要先檢查是否要反向擷取,如果要的話就要將-ss
與-to
對調,然後將-vf reverse
加入到指令中
ffmpeg -i "${episode}.mp4" -ss $(frame_to_ffmpeg_time ${frame_start} ${fps}) -to $(frame_to_ffmpeg_time ${frame_end} ${fps}) -vf reverse -f gif "${episode}.gif"
除此之外,對於開始跟結束frame相同的情況,我們可以直接rollback到擷取圖片的方式,以節省時間
擷取GIF用Go來實現的話就是
func ExtractGIF(episode string, startFrame, endFrame int, fps float64) (*bytes.Buffer, error) {
if runtime.GOOS == "windows" {
homePath = os.Getenv("USERPROFILE")
}
videoPath := fmt.Sprintf("%s/mygo-anime/%s.mp4", homePath, episode)
reverse := false
if startFrame > endFrame {
startFrame, endFrame = endFrame, startFrame
reverse = true
} else if startFrame == endFrame {
return ExtractFrame(episode, startFrame, fps)
}
startTime := FrameToTime(startFrame, fps)
endTime := FrameToTime(endFrame, fps)
buf := &bytes.Buffer{}
input := ffmpeg.Input(videoPath, ffmpeg.KwArgs{"ss": startTime, "to": endTime})
if reverse {
input = input.Filter("reverse", nil)
}
process := input.Output("pipe:", ffmpeg.KwArgs{"format": "gif", "vcodec": "gif"}).WithOutput(buf, os.Stdout)
err := process.Run()
if err != nil {
return nil, err
}
return buf, nil
}
附上ffmpeg-python的寫法
async def extract_gif(
episode: Literal["1-3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13"],
start_frame: int,
end_frame: int,
) -> bytes:
video_path = Path.home() / "mygo-anime" / f"{episode}.mp4"
reverse = False
if start_frame > end_frame:
start_frame, end_frame = end_frame, start_frame
reverse = True
elif start_frame == end_frame:
return extract_frame(
episode, start_frame
) # fallback to extract single frame
# process palettegen and paletteuse
input_stream = ffmpeg.input(
video_path,
ss=self._frame_to_time(start_frame, episode_data.frame_rate),
to=self._frame_to_time(end_frame, episode_data.frame_rate),
)
if reverse:
input_stream = input_stream.filter("reverse")
result, _ = input_stream.output(
"pipe:", vcodec="gif", format="gif"
).run(capture_stdout=True)
return result
那麼今天的文章就到這告一段落,如果我的文章有任何地方有錯誤請在留言區反應
明天將會把上面的功能串接至API,進而透過API來擷取指定frame的圖片或GIF